fux-decoder-html 4.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""`fux_decoder_html` — the fux-decoder-html package (W-289, SR-PLUGINS).
|
|
2
|
+
|
|
3
|
+
One fux decoder module per format it reads: `fux_decoder_html.html`. Each is the
|
|
4
|
+
decoder protocol of SR-DECODE — `EXTENSIONS`, `VERSION`, `USES` and
|
|
5
|
+
`decode(raw, rel_path)` — moved here unchanged from `src/fux/decode/` in
|
|
6
|
+
W-289 phase 2.
|
|
7
|
+
|
|
8
|
+
**fux loads a module from here only when `.fux/plugins.toml` declares this
|
|
9
|
+
package**, after verifying its version and digest (SR-PLUGINS P3, P4), by its
|
|
10
|
+
fixed import name (P7). A same-named file in `.fux/decoders/` overrides it
|
|
11
|
+
(P8). This file runs nothing: importing the package imports no decoder.
|
|
12
|
+
"""
|
fux_decoder_html/html.py
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""HTML -> Markdown. **Lifted, not written.**
|
|
2
|
+
|
|
3
|
+
This parser was already in the repo — twice. `.fux/fetchers/http.py` carried it
|
|
4
|
+
and `.fux/fetchers/cdp.py` carried a copy marked *"Kept identical to…"*, with
|
|
5
|
+
nothing keeping it identical. `http.py`'s own docstring stated the consequence
|
|
6
|
+
as a rule it could not enforce:
|
|
7
|
+
|
|
8
|
+
Both fetchers must produce the same markdown from the same bytes, or which
|
|
9
|
+
fetcher retrieved a document would change the committed index.
|
|
10
|
+
|
|
11
|
+
That is **same sources -> same index** (L4) demoted to a code comment. Moving
|
|
12
|
+
the pass here makes the requirement structurally true instead of asked for:
|
|
13
|
+
there is one implementation, and both fetchers reach it.
|
|
14
|
+
|
|
15
|
+
**Behaviour is preserved deliberately, including its rough edges.** Anything
|
|
16
|
+
this emits differently from the fetcher copies would silently re-rank every
|
|
17
|
+
URL-sourced document already in a committed index. Changes to the conversion
|
|
18
|
+
belong in their own change, with the goldens re-blessed on purpose.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import codecs
|
|
24
|
+
import re
|
|
25
|
+
from html.parser import HTMLParser
|
|
26
|
+
from fux.constants import fixed
|
|
27
|
+
from fux_extension_table import table as _table
|
|
28
|
+
from fux.decode.ext.limits import limit
|
|
29
|
+
|
|
30
|
+
#: **The reuse key's handle on this decoder** (W-166). Bump it by hand in the
|
|
31
|
+
#: same change as any edit that can change what `decode()` returns, and the next
|
|
32
|
+
#: `fux ingest` re-extracts the documents bound to THIS decoder and no others.
|
|
33
|
+
#: Leaving it alone is the claim that the edit cannot move a byte of output.
|
|
34
|
+
#: `tests/decode/test_decoder_versions.py` fails on a changed module that did
|
|
35
|
+
#: not bump it. [SR-DECODE](../../../../records/0139_decode.md) decision 11a.
|
|
36
|
+
VERSION = fixed("decoders.html", "version") # 2 (W-287 A2/A4): thead/th header, colspan/rowspan, caption, nested tables, escaped pipes; not bumped by W-288 (imports moved to decode/ext, same output; USES now carries the extensions' VERSIONs)
|
|
37
|
+
|
|
38
|
+
EXTENSIONS = tuple(fixed("decoders.html", "extensions"))
|
|
39
|
+
#: The decoder extensions this decoder calls, read by the reuse key (SR-DECODE decision 24).
|
|
40
|
+
USES = tuple(fixed("decoders.html", "uses"))
|
|
41
|
+
|
|
42
|
+
_SKIP = {"script", "style", "head", "noscript", "template", "svg", "iframe"}
|
|
43
|
+
_BLOCK_BREAK = {"p", "div", "section", "article", "main", "header", "footer", "figure"}
|
|
44
|
+
#: `<h1>`..`<h6>` -- HTML's six levels are Markdown's (`constants.toml [markdown]`).
|
|
45
|
+
_HEADINGS = {f"h{level}": level for level in range(1, fixed("markdown", "max_heading") + 1)}
|
|
46
|
+
|
|
47
|
+
#: `<meta charset=...>` / `<meta http-equiv=content-type ... charset=...>`.
|
|
48
|
+
#: Only the first 4 KiB is searched, which is where the spec requires it and
|
|
49
|
+
#: where every real document puts it — and a bounded search keeps a pathological
|
|
50
|
+
#: file from turning encoding detection into a scan of the whole document.
|
|
51
|
+
_CHARSET_RE = re.compile(rb"""charset\s*=\s*["']?\s*([A-Za-z0-9_\-.:]+)""", re.IGNORECASE)
|
|
52
|
+
|
|
53
|
+
PREPEND_TITLE_HEADING = True
|
|
54
|
+
|
|
55
|
+
#: WHATWG's span clamps (`constants.toml [decoders.html.format]`).
|
|
56
|
+
_MAX_COLSPAN = fixed("decoders.html.format", "max_colspan")
|
|
57
|
+
_MAX_ROWSPAN = fixed("decoders.html.format", "max_rowspan")
|
|
58
|
+
_MAX_HEADING = fixed("markdown", "max_heading")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Table:
|
|
62
|
+
"""One `<table>` being read: its rows, the cell in progress, its caption.
|
|
63
|
+
|
|
64
|
+
W-287 A2. A row is `(cells, section)` and a cell `(text, is_th, colspan,
|
|
65
|
+
rowspan)` — the spans are expanded only when the table closes, because a
|
|
66
|
+
`rowspan` reaches rows that have not been read yet.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(self) -> None:
|
|
70
|
+
self.rows: list[tuple[list[tuple[str, bool, int, int]], str]] = []
|
|
71
|
+
self.cell: list[str] | None = None
|
|
72
|
+
self.cell_th = False
|
|
73
|
+
self.cell_span = (1, 1)
|
|
74
|
+
self.caption: list[str] | None = None
|
|
75
|
+
self.caption_text = ""
|
|
76
|
+
self.section = "tbody"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def decode(raw: bytes, rel_path: str) -> str | None:
|
|
80
|
+
"""One HTML document -> Markdown, or `None` when nothing was extractable.
|
|
81
|
+
|
|
82
|
+
`None` rather than an exception for an empty result: a page that is all
|
|
83
|
+
chrome and images has no prose to index, which is a fact about the page and
|
|
84
|
+
not a failure of the run.
|
|
85
|
+
"""
|
|
86
|
+
html = raw.decode(_charset(raw), errors="replace")
|
|
87
|
+
markdown = html_to_markdown(html)
|
|
88
|
+
title = extract_title(html)
|
|
89
|
+
heading = "# " + title
|
|
90
|
+
if PREPEND_TITLE_HEADING and title and not markdown.startswith(heading + "\n"):
|
|
91
|
+
markdown = heading + "\n\n" + markdown
|
|
92
|
+
return markdown if markdown.strip() else None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _charset(raw: bytes) -> str:
|
|
96
|
+
"""The document's declared encoding, or utf-8.
|
|
97
|
+
|
|
98
|
+
A fetcher learns this from the HTTP `Content-Type` header; a file on disk
|
|
99
|
+
has no header, so the declaration inside the bytes is all there is. An
|
|
100
|
+
unknown or hostile label falls back to utf-8 rather than raising — the
|
|
101
|
+
caller passes `errors="replace"`, so a wrong guess costs a few characters
|
|
102
|
+
and never the document.
|
|
103
|
+
"""
|
|
104
|
+
if raw.startswith(codecs.BOM_UTF8):
|
|
105
|
+
return "utf-8-sig"
|
|
106
|
+
match = _CHARSET_RE.search(raw[: limit("html", "charset_scan_bytes")])
|
|
107
|
+
if match:
|
|
108
|
+
label = match.group(1).decode("ascii", errors="replace").strip().lower()
|
|
109
|
+
try:
|
|
110
|
+
"".encode(label) # cheapest way to ask the codec registry
|
|
111
|
+
except (LookupError, TypeError, ValueError):
|
|
112
|
+
return "utf-8"
|
|
113
|
+
return label
|
|
114
|
+
return "utf-8"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def html_to_markdown(html: str) -> str:
|
|
118
|
+
parser = _MdParser()
|
|
119
|
+
parser.feed(html)
|
|
120
|
+
parser.close()
|
|
121
|
+
return parser.result()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def extract_title(html: str) -> str:
|
|
125
|
+
parser = _TitleParser()
|
|
126
|
+
parser.feed(html)
|
|
127
|
+
parser.close()
|
|
128
|
+
return parser.title.strip()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class _MdParser(HTMLParser):
|
|
132
|
+
def __init__(self) -> None:
|
|
133
|
+
super().__init__(convert_charrefs=True)
|
|
134
|
+
self.blocks: list[str] = []
|
|
135
|
+
self.inline: list[str] = []
|
|
136
|
+
self.skip_depth = 0
|
|
137
|
+
self.pre_depth = 0
|
|
138
|
+
self.pre_text: list[str] = []
|
|
139
|
+
self.heading: int | None = None
|
|
140
|
+
self.quote_depth = 0
|
|
141
|
+
self.list_stack: list[tuple[str, int]] = [] # (kind, counter)
|
|
142
|
+
self.href: list[str] = []
|
|
143
|
+
#: Open tables, innermost last. A table opened inside a cell is NESTED:
|
|
144
|
+
#: it is flattened into that cell when it closes (W-287 A2), where it
|
|
145
|
+
#: used to reset the outer table and lose every row read so far.
|
|
146
|
+
self.tables: list[_Table] = []
|
|
147
|
+
#: The level of the last heading emitted — a `<caption>` becomes a
|
|
148
|
+
#: heading one level below it. The title the decoder prepends is H1.
|
|
149
|
+
self.last_level = 1
|
|
150
|
+
|
|
151
|
+
# -- emit helpers ------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _flush_inline(self, prefix: str = "") -> None:
|
|
154
|
+
text = " ".join("".join(self.inline).split())
|
|
155
|
+
self.inline = []
|
|
156
|
+
if text:
|
|
157
|
+
quote = "> " * self.quote_depth
|
|
158
|
+
self.blocks.append(f"{quote}{prefix}{text}")
|
|
159
|
+
|
|
160
|
+
def _text(self, data: str) -> None:
|
|
161
|
+
table = self.tables[-1] if self.tables else None
|
|
162
|
+
if self.pre_depth:
|
|
163
|
+
self.pre_text.append(data)
|
|
164
|
+
elif table is not None and table.caption is not None:
|
|
165
|
+
table.caption.append(data)
|
|
166
|
+
elif table is not None and table.cell is not None:
|
|
167
|
+
table.cell.append(data)
|
|
168
|
+
else:
|
|
169
|
+
self.inline.append(data)
|
|
170
|
+
|
|
171
|
+
# -- parser events -----------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def handle_starttag(self, tag: str, attrs) -> None:
|
|
174
|
+
attrs = dict(attrs)
|
|
175
|
+
if tag in _SKIP:
|
|
176
|
+
self.skip_depth += 1
|
|
177
|
+
return
|
|
178
|
+
if self.skip_depth:
|
|
179
|
+
return
|
|
180
|
+
if tag == "pre":
|
|
181
|
+
self._flush_inline()
|
|
182
|
+
self.pre_depth += 1
|
|
183
|
+
elif tag in _HEADINGS:
|
|
184
|
+
self._flush_inline()
|
|
185
|
+
self.heading = _HEADINGS[tag]
|
|
186
|
+
elif tag in ("ul", "ol"):
|
|
187
|
+
self._flush_inline()
|
|
188
|
+
self.list_stack.append((tag, 0))
|
|
189
|
+
elif tag == "li":
|
|
190
|
+
self._flush_inline()
|
|
191
|
+
elif tag == "blockquote":
|
|
192
|
+
self._flush_inline()
|
|
193
|
+
self.quote_depth += 1
|
|
194
|
+
elif tag == "table":
|
|
195
|
+
if not self.tables:
|
|
196
|
+
self._flush_inline()
|
|
197
|
+
self.tables.append(_Table())
|
|
198
|
+
elif tag == "caption" and self.tables:
|
|
199
|
+
self.tables[-1].caption = []
|
|
200
|
+
elif tag in ("thead", "tbody", "tfoot") and self.tables:
|
|
201
|
+
self.tables[-1].section = tag
|
|
202
|
+
elif tag == "tr" and self.tables:
|
|
203
|
+
table = self.tables[-1]
|
|
204
|
+
self._close_cell(table)
|
|
205
|
+
table.rows.append(([], table.section))
|
|
206
|
+
elif tag in ("td", "th") and self.tables:
|
|
207
|
+
table = self.tables[-1]
|
|
208
|
+
self._close_cell(table)
|
|
209
|
+
table.cell = []
|
|
210
|
+
table.cell_th = tag == "th"
|
|
211
|
+
table.cell_span = (_span(attrs.get("colspan"), _MAX_COLSPAN), _span(attrs.get("rowspan"), _MAX_ROWSPAN))
|
|
212
|
+
elif tag == "a":
|
|
213
|
+
self.href.append(attrs.get("href", ""))
|
|
214
|
+
self._text("[")
|
|
215
|
+
elif tag == "img":
|
|
216
|
+
alt = attrs.get("alt", "") or "image"
|
|
217
|
+
src = attrs.get("src", "")
|
|
218
|
+
self._text(f"")
|
|
219
|
+
elif tag == "code" and not self.pre_depth:
|
|
220
|
+
self._text("`")
|
|
221
|
+
elif tag in ("b", "strong"):
|
|
222
|
+
self._text("**")
|
|
223
|
+
elif tag in ("i", "em"):
|
|
224
|
+
self._text("*")
|
|
225
|
+
elif tag == "br":
|
|
226
|
+
self._flush_inline()
|
|
227
|
+
elif tag in ("hr",):
|
|
228
|
+
self._flush_inline()
|
|
229
|
+
self.blocks.append("---")
|
|
230
|
+
elif tag in _BLOCK_BREAK:
|
|
231
|
+
self._flush_inline()
|
|
232
|
+
|
|
233
|
+
def handle_endtag(self, tag: str) -> None:
|
|
234
|
+
if tag in _SKIP:
|
|
235
|
+
self.skip_depth = max(0, self.skip_depth - 1)
|
|
236
|
+
return
|
|
237
|
+
if self.skip_depth:
|
|
238
|
+
return
|
|
239
|
+
if tag == "pre":
|
|
240
|
+
self.pre_depth = max(0, self.pre_depth - 1)
|
|
241
|
+
if not self.pre_depth:
|
|
242
|
+
code = "\n".join(
|
|
243
|
+
line.rstrip() for line in "".join(self.pre_text).strip("\n").split("\n")
|
|
244
|
+
)
|
|
245
|
+
self.pre_text = []
|
|
246
|
+
self.blocks.append(f"```\n{code}\n```")
|
|
247
|
+
elif tag in _HEADINGS:
|
|
248
|
+
level = self.heading or _HEADINGS[tag]
|
|
249
|
+
self._flush_inline(prefix="#" * level + " ")
|
|
250
|
+
self.heading = None
|
|
251
|
+
self.last_level = level
|
|
252
|
+
elif tag in ("ul", "ol"):
|
|
253
|
+
self._flush_inline()
|
|
254
|
+
if self.list_stack:
|
|
255
|
+
self.list_stack.pop()
|
|
256
|
+
elif tag == "li":
|
|
257
|
+
indent = " " * max(0, len(self.list_stack) - 1)
|
|
258
|
+
if self.list_stack and self.list_stack[-1][0] == "ol":
|
|
259
|
+
kind, count = self.list_stack[-1]
|
|
260
|
+
self.list_stack[-1] = (kind, count + 1)
|
|
261
|
+
self._flush_inline(prefix=f"{indent}{count + 1}. ")
|
|
262
|
+
else:
|
|
263
|
+
self._flush_inline(prefix=f"{indent}- ")
|
|
264
|
+
elif tag == "blockquote":
|
|
265
|
+
self._flush_inline()
|
|
266
|
+
self.quote_depth = max(0, self.quote_depth - 1)
|
|
267
|
+
elif tag in ("td", "th") and self.tables:
|
|
268
|
+
self._close_cell(self.tables[-1])
|
|
269
|
+
elif tag == "caption" and self.tables:
|
|
270
|
+
table = self.tables[-1]
|
|
271
|
+
if table.caption is not None:
|
|
272
|
+
table.caption_text = " ".join("".join(table.caption).split())
|
|
273
|
+
table.caption = None
|
|
274
|
+
elif tag in ("thead", "tbody", "tfoot") and self.tables:
|
|
275
|
+
self.tables[-1].section = "tbody"
|
|
276
|
+
elif tag == "table" and self.tables:
|
|
277
|
+
table = self.tables.pop()
|
|
278
|
+
self._close_cell(table)
|
|
279
|
+
if self.tables:
|
|
280
|
+
self._nest(table, self.tables[-1])
|
|
281
|
+
else:
|
|
282
|
+
self._add_table(table)
|
|
283
|
+
elif tag == "a":
|
|
284
|
+
href = self.href.pop() if self.href else ""
|
|
285
|
+
self._text(f"]({href})" if href else "]")
|
|
286
|
+
elif tag == "code" and not self.pre_depth:
|
|
287
|
+
self._text("`")
|
|
288
|
+
elif tag in ("b", "strong"):
|
|
289
|
+
self._text("**")
|
|
290
|
+
elif tag in ("i", "em"):
|
|
291
|
+
self._text("*")
|
|
292
|
+
elif tag in _BLOCK_BREAK:
|
|
293
|
+
self._flush_inline()
|
|
294
|
+
|
|
295
|
+
def handle_data(self, data: str) -> None:
|
|
296
|
+
if not self.skip_depth:
|
|
297
|
+
self._text(data)
|
|
298
|
+
|
|
299
|
+
def _close_cell(self, table: _Table) -> None:
|
|
300
|
+
"""End the cell in progress, if any — `</td>` is optional in HTML."""
|
|
301
|
+
if table.cell is None:
|
|
302
|
+
return
|
|
303
|
+
if table.rows:
|
|
304
|
+
text = " ".join("".join(table.cell).split())
|
|
305
|
+
colspan, rowspan = table.cell_span
|
|
306
|
+
table.rows[-1][0].append((text, table.cell_th, colspan, rowspan))
|
|
307
|
+
table.cell = None
|
|
308
|
+
|
|
309
|
+
def _nest(self, inner: _Table, outer: _Table) -> None:
|
|
310
|
+
"""A nested table, flattened into the outer cell it sits in: its caption,
|
|
311
|
+
then each row's cells, rows separated by `;`."""
|
|
312
|
+
rows = [" ".join(t for t, *_ in cells if t) for cells, _section in inner.rows]
|
|
313
|
+
text = "; ".join(r for r in [inner.caption_text, *rows] if r)
|
|
314
|
+
if outer.cell is not None and text:
|
|
315
|
+
outer.cell.append(" " + text + " ")
|
|
316
|
+
|
|
317
|
+
def _add_table(self, table: _Table) -> None:
|
|
318
|
+
"""W-287 A2/A4 — the table as a `Grid` for `ext.table.emit`, which writes
|
|
319
|
+
it; this only places the result, and writes no table of its own (W-288).
|
|
320
|
+
A caption is its heading, one level below the last heading emitted."""
|
|
321
|
+
grid = _grid(table.rows)
|
|
322
|
+
# Read only when the table declares no header: a `<th>` table needs no
|
|
323
|
+
# threshold, and `html_to_markdown` is called with no repository bound.
|
|
324
|
+
percent = None if grid.header else limit("html", "header_fill_percent")
|
|
325
|
+
out = _table.emit(
|
|
326
|
+
grid,
|
|
327
|
+
header_fill_percent=percent,
|
|
328
|
+
row_limit=None,
|
|
329
|
+
max_cell_chars=None,
|
|
330
|
+
)
|
|
331
|
+
if not out.markdown:
|
|
332
|
+
if table.caption_text:
|
|
333
|
+
self.blocks.append(table.caption_text)
|
|
334
|
+
return
|
|
335
|
+
if table.caption_text:
|
|
336
|
+
level = min(self.last_level + 1, _MAX_HEADING)
|
|
337
|
+
self.blocks.append("#" * level + " " + table.caption_text)
|
|
338
|
+
if out.prose:
|
|
339
|
+
self.blocks.append("\n".join(out.prose))
|
|
340
|
+
self.blocks.append(out.markdown)
|
|
341
|
+
|
|
342
|
+
def result(self) -> str:
|
|
343
|
+
self._flush_inline()
|
|
344
|
+
out = "\n\n".join(b for b in self.blocks if b.strip())
|
|
345
|
+
return out + "\n" if out else ""
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _span(value, cap: int) -> int:
|
|
349
|
+
"""A `colspan`/`rowspan` attribute as a count: 1 when absent or nonsense,
|
|
350
|
+
clamped to WHATWG's ceiling. `rowspan="0"` spans the rest of the table."""
|
|
351
|
+
try:
|
|
352
|
+
n = int(str(value).strip())
|
|
353
|
+
except (TypeError, ValueError):
|
|
354
|
+
return 1
|
|
355
|
+
if n == 0:
|
|
356
|
+
return cap
|
|
357
|
+
return max(1, min(n, cap))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _grid(rows) -> _table.Grid:
|
|
361
|
+
"""The rows an HTML table held -> a `_table.Grid` (W-287 A2).
|
|
362
|
+
|
|
363
|
+
A cell is placed at the first column no earlier `rowspan` still holds;
|
|
364
|
+
`colspan`/`rowspan` become a span; a row with no cells is no row. The
|
|
365
|
+
declared header is the `<thead>` rows, else a leading run of rows whose
|
|
366
|
+
every cell is a `<th>`; with neither, `_table.emit` finds it by fill.
|
|
367
|
+
"""
|
|
368
|
+
grid = _table.Grid()
|
|
369
|
+
pending: dict[int, int] = {} # column -> rows still covered below
|
|
370
|
+
sections: list[str] = []
|
|
371
|
+
all_th: list[bool] = []
|
|
372
|
+
r = -1
|
|
373
|
+
for cells, section in rows:
|
|
374
|
+
if not cells:
|
|
375
|
+
continue
|
|
376
|
+
r += 1
|
|
377
|
+
sections.append(section)
|
|
378
|
+
all_th.append(all(th for _t, th, *_ in cells))
|
|
379
|
+
occupied = set(pending)
|
|
380
|
+
for col, left in list(pending.items()):
|
|
381
|
+
if left <= 1:
|
|
382
|
+
del pending[col]
|
|
383
|
+
else:
|
|
384
|
+
pending[col] = left - 1
|
|
385
|
+
col = 0
|
|
386
|
+
for text, _th, colspan, rowspan in cells:
|
|
387
|
+
while col in occupied:
|
|
388
|
+
col += 1
|
|
389
|
+
grid.cells[(r, col)] = _table.Cell(text, "text")
|
|
390
|
+
if colspan > 1 or rowspan > 1:
|
|
391
|
+
grid.spans.append((r, col, r + rowspan - 1, col + colspan - 1))
|
|
392
|
+
for k in range(colspan):
|
|
393
|
+
occupied.add(col + k)
|
|
394
|
+
if rowspan > 1:
|
|
395
|
+
pending[col + k] = rowspan - 1
|
|
396
|
+
col += colspan
|
|
397
|
+
head = [i for i, section in enumerate(sections) if section == "thead"]
|
|
398
|
+
if not head:
|
|
399
|
+
while len(head) < len(all_th) and all_th[len(head)]:
|
|
400
|
+
head.append(len(head))
|
|
401
|
+
grid.header = tuple(head)
|
|
402
|
+
return grid
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class _TitleParser(HTMLParser):
|
|
406
|
+
def __init__(self) -> None:
|
|
407
|
+
super().__init__(convert_charrefs=True)
|
|
408
|
+
self.title = ""
|
|
409
|
+
self._in_title = False
|
|
410
|
+
|
|
411
|
+
def handle_starttag(self, tag: str, attrs) -> None:
|
|
412
|
+
if tag == "title" and not self.title:
|
|
413
|
+
self._in_title = True
|
|
414
|
+
|
|
415
|
+
def handle_endtag(self, tag: str) -> None:
|
|
416
|
+
if tag == "title":
|
|
417
|
+
self._in_title = False
|
|
418
|
+
|
|
419
|
+
def handle_data(self, data: str) -> None:
|
|
420
|
+
if self._in_title:
|
|
421
|
+
self.title += data
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fux-decoder-html
|
|
3
|
+
Version: 4.1.1
|
|
4
|
+
Summary: Fux decoder package: HTML.
|
|
5
|
+
Project-URL: Homepage, https://github.com/arpitarya/fux
|
|
6
|
+
Project-URL: Repository, https://github.com/arpitarya/fux
|
|
7
|
+
Author-email: arpit arya <arpitarya.me@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Requires-Dist: fux-core==4.1.1
|
|
11
|
+
Requires-Dist: fux-extension-table==4.1.1
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# fux-decoder-html
|
|
15
|
+
|
|
16
|
+
Part of [fux](https://github.com/arpitarya/fux) — rank your written knowledge
|
|
17
|
+
from a small index committed to git; fetch content from the systems that own
|
|
18
|
+
it; verify freshness at answer time.
|
|
19
|
+
|
|
20
|
+
**`fux-decoder-html` is a fux decoder package: HTML.** Only `fux-engine` is mandatory;
|
|
21
|
+
everything else is declared per repository in the committed
|
|
22
|
+
`.fux/plugins.toml`, pinned to one version and digest, and refused loudly when
|
|
23
|
+
it is missing or differs, so two machines on one repository never build
|
|
24
|
+
different indexes.
|
|
25
|
+
|
|
26
|
+
It reads `.html` `.htm` `.xhtml`. Install it and declare it in `.fux/plugins.toml` --
|
|
27
|
+
`fux setup` proposes it when your sources hold those files -- or fux does not
|
|
28
|
+
read them, and `fux doctor` says so.
|
|
29
|
+
|
|
30
|
+
It is released on fux's own version, together with every other fux package.
|
|
31
|
+
Documentation: <https://github.com/arpitarya/fux#readme>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
fux_decoder_html/__init__.py,sha256=lJT0fwRaqTd-ggFuvSwf9gU3ZO6_MgTIGX74AlAKGHQ,623
|
|
2
|
+
fux_decoder_html/html.py,sha256=W5onAZJKcDmF3gDQt2yOQikRXg8E3g6zeaQHdsUiubo,16818
|
|
3
|
+
fux_decoder_html-4.1.1.dist-info/METADATA,sha256=uxKoo42GkKxICGfGk_JbRI6gR6zQ8NNDG1ajVf5vW_A,1290
|
|
4
|
+
fux_decoder_html-4.1.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
fux_decoder_html-4.1.1.dist-info/RECORD,,
|